04 ImageLoader 图片加载

01 基础框架
02 请求队列
03 三级缓存
04 图片加载
05 常见问题
06 项目源码

图片加载

上级博客的三级缓存只针对了网络图片加载的情况,如果我们要加载其他来源的图片该如何呢?比如本地图片(file://)、或者程序本身的图片(drawable://),又或者用户需要读取指定外存的图片时该如何?所以我们加载图片的代码不能是硬编码,需要为后期扩展提供功能。要保证扩展性,就必须抽象。

1
2
3
public interface ILoader {
void loadImage(BitmapRequest result);
}

ILoader 只定义了一个接口,只用一个加载图片的方法。

我们再来看看现有的 Loader 的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
public class Loader {
private static Loader instance = new Loader();
private Loader() {}
public static Loader getInstance() {
return instance;
}
public void load(BitmapRequest request) {
if (null == request || null == request.imageView
|| TextUtils.isEmpty(request.imageUri)) {
return;
}
String uri = request.imageUri;
// 先从内存中读取
Bitmap bitmap = ImageLoader.getInstance().getCache().get(request);
// 没有就去加载网络图片
if (null == bitmap) {
bitmap = downloadImage(uri);
if (null != bitmap) {
ImageLoader.getInstance().getCache().put(request, bitmap);
}
}
// 通知界面更新
deliveryToUIThread(request, bitmap);
}
/**
* 加载网络图片
* @param imageUrl 图片地址
* @return Bitmap
*/
private Bitmap downloadImage(String imageUrl) {
if (TextUtils.isEmpty(imageUrl)) {
return null;
}
Bitmap bitmap = null;
try {
URL url = new URL(imageUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
bitmap = BitmapFactory.decodeStream(conn.getInputStream());
conn.disconnect();
} catch (Exception e) {
e.printStackTrace();
}
return bitmap;
}
/**
* 更新视图控件,显示图片
* @param request ImageView
* @param bitmap Bitmap
*/
private void deliveryToUIThread(final BitmapRequest request, final Bitmap bitmap) {
final ImageView imageView = request.imageView;
if (null != imageView) {
imageView.post(new Runnable() {
@Override
public void run() {
updateImageView(request, bitmap);
}
});
}
}
// 更新ImageView
private void updateImageView(BitmapRequest request, Bitmap result) {
// ...
}
}

可以见得加载图片的过程如下:

  1. 判断缓存中是否含有该图片;
  2. 如果有则将图片直接投递到UI线程,并且更新UI;
  3. 如果没有缓存,则从对应的地方获取到图片,并且将图片缓存起来,然后再将结果投递给UI线程,更新UI;

从本地、程序资源、assets 中加载图片的过程是怎样的呢?跟加载网络图片的过程有何区别。我们可以发现,不管从哪里加载图片,这些逻辑都是通用的,因此可以抽象一个 AbsLoader 类。它将这几个过程抽象起来,只将变化的部分交给子类处理,就相当于 AbsLoader 封装了一个逻辑框架( 模板方法模式),大致代码如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public abstract class AbsLoader implements ILoader {
// 图片缓存
private static IBitmapCache mCache = ImageLoader.getInstance().getCache();
@Override
public void loadImage(BitmapRequest request) {
if (null == request || null == request.imageView
|| TextUtils.isEmpty(request.imageUri)) {
return;
}
// 1、从缓存中获取
Bitmap bitmap = ImageLoader.getInstance().getCache().get(request);
// 2、没有缓存,调用 onLoaderImage 加载图片
if (null == bitmap) {
bitmap = onLoadImage(request);
// 3、缓存图片
cacheBitmap(request, bitmap);
}
// 4、将结果投递到UI线程
deliveryToUIThread(request, bitmap);
}
protected abstract Bitmap onLoadImage(BitmapRequest result);
private void cacheBitmap(BitmapRequest request, Bitmap bitmap) {
// 缓存新的图片
if (bitmap != null && mCache != null) {
synchronized (mCache) {
mCache.put(request, bitmap);
}
}
}
/**
* 更新视图控件,显示图片
* @param request ImageView
* @param bitmap Bitmap
*/
private void deliveryToUIThread(final BitmapRequest request, final Bitmap bitmap) {
final ImageView imageView = request.imageView;
if (null != imageView) {
imageView.post(new Runnable() {
@Override
public void run() {
updateImageView(request, bitmap);
}
});
}
}
/**
* 更新ImageView
*/
private void updateImageView(BitmapRequest request, Bitmap result) {
ImageView imageView = request.imageView;
if (result != null && request.isImageViewTagValid()) {
imageView.setImageBitmap(result);
}
}
}

代码逻辑如上所述实现了一个模板函数,变化的部分就是 onLoadImage,子类在这里实现真正的加载图片的方法。比如从网络上加载图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class UrlLoader extends AbsLoader {
@Override
protected Bitmap onLoadImage(BitmapRequest request) {
if (null == request || TextUtils.isEmpty(request.imageUri)) {
return null;
}
Bitmap bitmap = null;
try {
URL url = new URL(request.imageUri);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
bitmap = BitmapFactory.decodeStream(conn.getInputStream());
conn.disconnect();
} catch (Exception e) {
e.printStackTrace();
}
return bitmap;
}
}

按照上述代码,我们再一个本地图片加载实现类(LocalLoader)。

1
2
3
4
5
6
7
8
9
10
11
12
public class LocalLoader extends AbsLoader {
@Override
public Bitmap onLoadImage(BitmapRequest request) {
final String imagePath = Uri.parse(request.imageUri).getPath();
final File imgFile = new File(imagePath);
if (!imgFile.exists()) {
return null;
}
return BitmapFactory.decodeFile(imagePath);
}
}

我们的程序该根据什么条件来选择加载器呢?我们加载网络图片的 uri 格式统一为 http://xxx/image.jpg,或者 https 打头;而本地图片的 uri 为 file://sdcard/xxx/image.jpg。也就是说 uri 格式为 schema:// + 图片路径,而 schema 的值可能是:HTTP(“http”), HTTPS(“https”), FILE(“file”), CONTENT(“content”), ASSETS(“assets”), DRAWABLE(“drawable”), UNKNOWN(“”)。现在我们可以创建一个枚举类 Scheme,负责声明各个常量值以及解析图片 uri。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public enum Scheme {
HTTP("http"), HTTPS("https"), FILE("file"), CONTENT("content"),
ASSETS("assets"), DRAWABLE("drawable"), UNKNOWN("");
private String scheme;
private String uriPrefix;
Scheme(String scheme) {
this.scheme = scheme;
uriPrefix = scheme + "://";
}
public static Scheme ofUri(String uri) {
if (uri != null) {
for (Scheme s : values()) {
if (s.belongsTo(uri)) {
return s;
}
}
}
return UNKNOWN;
}
private boolean belongsTo(String uri) {
return uri.toLowerCase(Locale.US).startsWith(uriPrefix);
}
public String wrap(String path) {
return uriPrefix + path;
}
public String crop(String uri) {
if (!belongsTo(uri)) {
throw new IllegalArgumentException(String.format("URI [%1$s] " +
"doesn't have expected scheme [%2$s]", uri, scheme));
}
return uri.substring(uriPrefix.length());
}
}

如果你要实现自己的 Loader 来加载特定的格式,那么它的 uri 格式必须以 schema:// 开头,否则解析会错误,例如可以为 drawable://image,然后你注册一个 schema 为 “drawable” 的Loader到 LoaderManager 中,ImageLoader 在加载图片时就会使用你注册的 Loader 来加载图片,这样就可以应对用户的多种多样的需求。如果不能拥抱变化那就不能称之为框架了,应该叫功能模块。

其中 LoaderManager 代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class LoaderManager {
private Map<Scheme, ILoader> mLoaderMap = new HashMap<>();
private ILoader mNullLoader = new NullLoader();
private static LoaderManager INSTANCE;
private LoaderManager() {
register(Scheme.HTTP, new UrlLoader());
register(Scheme.HTTPS, new UrlLoader());
register(Scheme.FILE, new LocalLoader());
}
public static LoaderManager getInstance() {
if (INSTANCE == null) {
synchronized (LoaderManager.class) {
if (INSTANCE == null) {
INSTANCE = new LoaderManager();
}
}
}
return INSTANCE;
}
// 注册 Loader
public final synchronized void register(Scheme schema, ILoader loader) {
mLoaderMap.put(schema, loader);
}
// 根据 schema 获取对应的 Loader
public ILoader getLoader(Scheme schema) {
if (mLoaderMap.containsKey(schema)) {
return mLoaderMap.get(schema);
}
return mNullLoader;
}
}

LoaderManager 负责管理 ILoader 的实现类,默认注册了 HTTP、HTTPS、FILE 三种 Scheme 及对应的实现加载类。如果用户自定义图片加载类,那么需要将它注册到 LoaderManager 中。注册方式为:

1
2
3
4
5
6
7
8
LoaderManager.getInstance().register(Scheme.DRAWABLE, new DrawableLoader());
LoaderManager.getInstance().register(Scheme.ASSETS, new ILoader() {
@Override
public void loadImage(BitmapRequest result) {
// 自定义图片加载
}
});

然后修改 RequestDispatcher 中相应的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class RequestDispatcher extends Thread {
// ...
@Override
public void run() {
try {
while (!this.isInterrupted()) {
final BitmapRequest request = mBitmapRequestQueue.take();
if (request.isCancel) {
continue;
}
Scheme scheme = Scheme.ofUri(request.imageUri);
ILoader loader = LoaderManager.getInstance().getLoader(scheme);
if (loader != null) {
loader.loadImage(request);
}
}
} catch (InterruptedException e) {
Log.i("", "### 请求分发器退出");
}
}
}

图片解码

我们定义的 UrlLoader 和 LocalLoader 都使用 BitmapFactory 默认的解码方式来获取位图(Bitmap)。如果用来展示图片的 ImageView 的宽高是位图的宽高的几分之一,那么直接加载原图而不进行缩放,明显浪费内存,甚至可能出现加载一张大图而导致 OOM 的极端情况。那么我们必须提供相关 API 以便用户选择某种方式对图片进行缩放。解析图片的一般过程如下:

  1. 创建 BitmapFactory.Options options,设置 options.inJustDecodeBounds = true,使得只解析图片尺寸等信息;
  2. 根据 ImageView 的尺寸来检查是否需要缩小要加载的图片以及计算缩放比例;
  3. 设置 options.inJustDecodeBounds = false,然后按照 options 设置的缩小比例来加载图片。

我们来创建一个类:BitmapDecoder,它使用 decodeBitmap 方法封装上述过程 ( 模板方法模式 ),用户只需要实现一个子类,并且覆写 BitmapDecoder 的 decodeBitmapWithOption 实现图片加载即可完成这个过程。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
* 封装先加载图片 bound,计算出 inSmallSize 之后再加载图片的逻辑操作
*/
public abstract class BitmapDecoder {
public abstract Bitmap decodeBitmapWithOption(Options options);
public final Bitmap decodeBitmap(int width, int height) {
// 如果请求原图,则直接加载原图
if (width <= 0 || height <= 0) {
return decodeBitmapWithOption(null);
}
// 1、获取只加载Bitmap宽高等数据的Option, 即设置options.inJustDecodeBounds = true;
BitmapFactory.Options options = new BitmapFactory.Options();
// 设置为true,表示解析Bitmap对象,该对象不占内存
options.inJustDecodeBounds = true;
// 2、通过options加载bitmap,此时返回的bitmap为空,数据将存储在options中
decodeBitmapWithOption(options);
// 3、计算缩放比例, 并且将options.inJustDecodeBounds设置为false;
configBitmapOptions(options, width, height);
// 4、通过options设置的缩放比例加载图片
return decodeBitmapWithOption(options);
}
// 加载原图
public Bitmap decodeOriginBitmap() {
return decodeBitmapWithOption(null);
}
protected void configBitmapOptions(Options options, int width, int height) {
// 设置缩放比例
options.inSampleSize = computeInSmallSize(options, width, height);
Log.d("", "$## inSampleSize = " + options.inSampleSize
+ ", width = " + width + ", height= " + height);
// 图片质量
options.inPreferredConfig = Config.RGB_565;
// 设置为false,解析Bitmap对象加入到内存中
options.inJustDecodeBounds = false;
options.inPurgeable = true;
options.inInputShareable = true;
}
private int computeInSmallSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
// Calculate ratios of height and width to requested height and
// width
final int heightRatio = Math.round((float) height / (float) reqHeight);
final int widthRatio = Math.round((float) width / (float) reqWidth);
inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio;
final float totalPixels = width * height;
final float totalReqPixelsCap = reqWidth * reqHeight * 2;
while (totalPixels / (inSampleSize * inSampleSize) > totalReqPixelsCap) {
inSampleSize++;
}
}
return inSampleSize;
}
}

在 decodeBitmap 中,我们首先创建 BitmapFactory.Options 对象,并且设置 options.inJustDecodeBounds = true,然后第一次调用 decodeBitmapWithOption(options),使得只解析图片尺寸等信息;然后调用 calculateInSmall 方法,该方法会调用 computeInSmallSize 来根据 ImageView 的尺寸来检查是否需要缩小要加载的图片以及计算缩放比例,在 calculateInSmall 方法的最后将
options.inJustDecodeBounds = false,使得下次再次 decodeBitmapWithOption(options) 时会加载图片;那最后一步必然就是调用 decodeBitmapWithOption(options),这样图片就会按照按照 options 设置的缩小比例来加载图片。

我们使用这个辅助类封装了这个麻烦、重复的过程,在一定程度上简化了代码,也使得代码的可复用性更高,也是模板方法模式的一个较好的示例。

然后我们在 LocalLoader 类使用 BitmapDecoder,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class LocalLoader extends AbsLoader {
@Override
public Bitmap onLoadImage(BitmapRequest request) {
final String imagePath = Uri.parse(request.imageUri).getPath();
final File imgFile = new File(imagePath);
if (!imgFile.exists()) {
return null;
}
// 加载图片
BitmapDecoder decoder = new BitmapDecoder() {
@Override
public Bitmap decodeBitmapWithOption(Options options) {
return BitmapFactory.decodeFile(imagePath, options);
}
};
return decoder.decodeBitmap(request.getImageViewWidth(),
request.getImageViewHeight());
// return BitmapFactory.decodeFile(imagePath);
}
}